<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>过拟合 vs 欠拟合</title>
  <style>
    :root {
      --bg: #0b1324;
      --panel: #111a32;
      --muted: #7d8db5;
      --text: #e8eeff;
      --brand: #5ad6ff;
      --ok: #46d082;
      --warn: #ffb84d;
      --bad: #ff6b6b;
      --accent: #c0a6ff;
    }

    html,
    body {
      height: 100%
    }

    body {
      margin: 0;
      background: linear-gradient(160deg, #0b1324, #0c1a3a 45%, #0b1324);
      color: var(--text);
      font: 16px/1.55 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif
    }

    header {
      position: sticky;
      top: 0;
      z-index: 10;
      background: rgba(10, 18, 40, .75);
      backdrop-filter: blur(6px);
      border-bottom: 1px solid #1d2a52
    }

    .wrap {
      max-width: 1100px;
      margin: 0 auto;
      padding: 14px 18px
    }

    h1 {
      font-size: 22px;
      margin: 0;
      display: flex;
      gap: .6rem;
      align-items: center
    }

    h1 span.badge {
      font-size: 12px;
      padding: .15rem .5rem;
      border-radius: 999px;
      background: linear-gradient(45deg, var(--brand), var(--accent));
      color: #00192a
    }

    .tabs {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-top: 8px
    }

    .tabs button {
      appearance: none;
      border: 1px solid #283769;
      background: #0e1c3b;
      color: var(--text);
      padding: .5rem .8rem;
      border-radius: 10px;
      cursor: pointer;
      transition: all .2s
    }

    .tabs button[aria-selected="true"],
    .tabs button:hover {
      border-color: var(--brand);
      box-shadow: 0 0 0 2px rgba(90, 214, 255, .15) inset
    }

    main {
      max-width: 1100px;
      margin: 14px auto;
      padding: 0 18px 26px
    }

    section.panel {
      display: none;
      background: linear-gradient(180deg, #0e1730, #0a1226);
      border: 1px solid #1d2a52;
      border-radius: 14px;
      padding: 16px;
      margin-top: 12px
    }

    section.panel.active {
      display: block
    }

    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
      gap: 14px
    }

    .card {
      background: radial-gradient(120% 120% at 0% 0%, #0f2352, #0b1736 55%, #0a1531);
      border: 1px solid #1d2a52;
      border-radius: 14px;
      padding: 14px;
      box-shadow: 0 6px 20px rgba(0, 0, 0, .25)
    }

    .card h3 {
      margin: .2rem 0 .4rem;
      font-size: 18px
    }

    .card p {
      margin: .25rem 0;
      color: #c3d2ff
    }

    .pill {
      display: inline-block;
      border: 1px solid #334a86;
      border-radius: 999px;
      padding: .15rem .5rem;
      font-size: 12px;
      color: #b9c8ff;
      margin-right: 6px
    }

    .tool {
      display: flex;
      gap: 14px;
      flex-wrap: wrap;
      align-items: center;
      margin: 8px 0 10px
    }

    .tool label {
      font-size: 14px;
      color: #c9d7ff
    }

    .btn {
      appearance: none;
      border: 1px solid #355099;
      background: #11214a;
      color: #eaf2ff;
      padding: .45rem .7rem;
      border-radius: 10px;
      cursor: pointer
    }

    .btn:hover {
      border-color: var(--brand)
    }

    .inline {
      display: inline-flex;
      align-items: center;
      gap: .5rem
    }

    canvas {
      background: #0a142c;
      border: 1px solid #1d2a52;
      border-radius: 10px;
      max-width: 100%
    }

    .row {
      display: grid;
      grid-template-columns: 1.1fr .9fr;
      gap: 16px
    }

    .legend {
      font-size: 13px;
      color: #bcd0ff
    }

    .metrics {
      display: flex;
      gap: 10px;
      flex-wrap: wrap
    }

    .metric {
      background: #0f1e3e;
      border: 1px solid #2d3e74;
      border-radius: 10px;
      padding: 10px;
      min-width: 140px
    }

    .metric .num {
      font-weight: 800;
      color: var(--brand)
    }

    .verdict {
      font-weight: 700
    }

    .good {
      color: var(--ok)
    }

    .over {
      color: var(--bad)
    }

    .under {
      color: var(--warn)
    }

    details {
      background: #0e1d3f;
      border: 1px solid #2a3e79;
      border-radius: 10px;
      padding: 10px
    }

    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 8px
    }

    th,
    td {
      border: 1px solid #23356a;
      padding: 10px;
      text-align: left
    }

    th {
      background: #0f1e3e
    }

    tr:hover td {
      background: #0e1d3f
    }

    .quiz-q {
      font-size: 18px;
      font-weight: 700;
      margin: 4px 0 10px
    }

    .opt {
      padding: 12px;
      background: #0f1e3e;
      border: 1px solid #2d3e74;
      border-radius: 8px;
      cursor: pointer;
      margin: 8px 0
    }

    .opt:hover {
      border-color: var(--brand);
      background: #132553
    }

    .opt.correct {
      background: rgba(70, 208, 130, .2);
      border-color: rgba(70, 208, 130, .8)
    }

    .opt.wrong {
      background: rgba(255, 107, 107, .2);
      border-color: rgba(255, 107, 107, .8)
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
      gap: 10px
    }

    .stat-card {
      background: #0f1e3e;
      border: 1px solid #2d3e74;
      border-radius: 8px;
      padding: 15px;
      text-align: center;
      min-width: 80px;
    }

    .stat-number {
      font-size: 24px;
      font-weight: bold;
      color: var(--brand);
    }

    .stat-label {
      font-size: 14px;
      color: #b7c6ff;
    }


    @media(max-width:880px) {
      .row {
        grid-template-columns: 1fr
      }
    }
  </style>
</head>

<body>
  <header>
    <div class="wrap">
      <h1>过拟合 vs 欠拟合 <span class="badge">欠过拟合解决方法</span></h1>
      <div class="tabs" role="tablist" aria-label="主标签">
        <button role="tab" aria-selected="true" aria-controls="concept" id="t-concept">① 现象与定义</button>
        <button role="tab" aria-selected="false" aria-controls="lab" id="t-lab">② 交互实验</button>
        <button role="tab" aria-selected="false" aria-controls="rx" id="t-rx">③ 解决方案</button>
        <button role="tab" aria-selected="false" aria-controls="quiz" id="t-quiz">④ 小测试</button>
      </div>
    </div>
  </header>

  <main>
    <!-- ① 概念 -->
    <section id="concept" class="panel active" role="tabpanel" aria-labelledby="t-concept">
      <div class="grid">
        <article class="card">
          <h3>欠拟合（Underfitting）</h3>
          <p>模型太简单，学习能力不足→<strong>训练误差高</strong>、验证/测试误差也高。</p>
          <p class="legend"><span class="pill">症状：两条学习曲线都高</span><span class="pill">原因：偏差大</span></p>
        </article>
        <article class="card">
          <h3>过拟合（Overfitting）</h3>
          <p>模型过于复杂，记住了训练数据→<strong>训练误差低</strong>但<strong>验证/测试误差高</strong>。</p>
          <p class="legend"><span class="pill">症状：泛化差、间隙大</span><span class="pill">原因：方差大</span></p>
        </article>
        <article class="card">
          <h3>刚刚好（Good Fit）</h3>
          <p>合适的复杂度与正则，训练与验证误差都低且接近，具备良好泛化。</p>
          <p class="legend"><span class="pill">目标：低偏差 + 低方差</span></p>
        </article>
        <article class="card">
          <h3>如何诊断？</h3>
          <p>观察训练/验证误差、间隙和学习曲线；留出集或交叉验证；监控早停点。</p>
          <details>
            <summary>学习曲线形状小抄</summary>
            <p>欠拟合：训练误差高→继续训练也难降；增加模型容量有帮助。</p>
            <p>过拟合：训练误差很低但验证误差上升→增加正则/数据/早停。</p>
          </details>
        </article>
      </div>
    </section>

    <!-- ② 实验室 -->
    <section id="lab" class="panel" role="tabpanel" aria-labelledby="t-lab">
      <div class="tool" aria-label="控制面板">
        <label>样本数 N：<input type="range" id="n" min="20" max="400" value="120"> <span id="nVal">120</span></label>
        <label>噪声 σ：<input type="range" id="sigma" min="0" max="0.6" step="0.02" value="0.15"> <span
            id="sVal">0.15</span></label>
        <label>复杂度（多项式阶）d：<input type="range" id="deg" min="1" max="15" value="7"> <span id="dVal">7</span></label>
        <label>L2 正则 λ：<input type="range" id="lam" min="0" max="5" step="0.05" value="0.4"> <span
            id="lVal">0.40</span></label>
        <label>训练占比：<input type="range" id="split" min="0.4" max="0.9" step="0.05" value="0.7"> <span
            id="spVal">70%</span></label>
        <button class="btn" id="regen">重新生成数据</button>
        <button class="btn" id="sweep">扫描复杂度</button>
        <button class="btn" id="demo-underfit" style="background:#ffb84d">演示欠拟合</button>
        <button class="btn" id="demo-overfit" style="background:#ff6b6b">演示过拟合</button>
        <button class="btn" id="demo-goodfit" style="background:#46d082">演示良好拟合</button>
      </div>

      <div class="row">
        <div>
          <canvas id="plot" width="620" height="380" aria-label="数据与拟合曲线"></canvas>
          <p class="legend">蓝点=训练集，橙点=验证集；白线=当前模型拟合曲线。</p>
          <div class="metrics" aria-live="polite">
            <div class="metric">
              <div>训练 MSE</div>
              <div class="num" id="mTrain">—</div>
            </div>
            <div class="metric">
              <div>验证 MSE</div>
              <div class="num" id="mVal">—</div>
            </div>
            <div class="metric">
              <div>误差间隙</div>
              <div class="num" id="gap">—</div>
            </div>
            <div class="metric">
              <div>判定</div>
              <div class="verdict" id="verdict">—</div>
            </div>
          </div>
        </div>
        <div>
          <canvas id="lc" width="520" height="300" aria-label="复杂度-误差曲线"></canvas>
          <p class="legend">点击“扫描复杂度”绘制<strong>训练</strong>与<strong>验证</strong>误差随 d 的变化，寻找甜蜜点（可视化早停）。</p>
          <details>
            <summary>如何读图？</summary>
            <p>随着 d↑，训练误差通常单调下降；验证误差先降后升。验证曲线的最低点对应合适的复杂度/早停点。</p>
          </details>
        </div>
      </div>
    </section>

    <!-- ③ 处方 -->
    <section id="rx" class="panel" role="tabpanel" aria-labelledby="t-rx">
      <div class="grid">
        <article class="card">
          <h3>如果是欠拟合（偏差大）</h3>
          <ul>
            <li>↑ 模型容量：更深/更宽/更高阶特征</li>
            <li>↓ 正则强度（λ）或放宽剪枝</li>
            <li>↑ 训练时长 / 更好的优化器</li>
            <li>↑ 特征工程与互项/非线性映射</li>
            <li>↓ 数据噪声（清洗标签/去异常）</li>
          </ul>
        </article>
        <article class="card">
          <h3>如果是过拟合（方差大）</h3>
          <ul>
            <li>↑ 数据量：收集/合成/数据增强</li>
            <li>↑ 正则化：L2/L1、权重衰减、dropout、数据噪声注入</li>
            <li>↑ 早停：监控验证集，拐点即停</li>
            <li>↓ 模型容量：更浅/更少特征/简化架构</li>
            <li>↑ 交叉验证与合理的超参搜索</li>
          </ul>
        </article>
        <article class="card">
          <h3>症状 → 处方速配</h3>
          <div class="tool">
            <label class="inline">选择症状：
              <select id="sym">
                <option value="bothHigh">训练与验证误差都高</option>
                <option value="gap">验证远大于训练（间隙大）</option>
                <option value="plateau">训练下降很慢/停滞</option>
                <option value="noisy">数据噪声/标签有错</option>
              </select>
            </label>
            <button class="btn" id="suggest">给我建议</button>
          </div>
          <table id="rxTable" aria-label="建议表">
            <thead>
              <tr>
                <th>优先级</th>
                <th>建议</th>
                <th>原因</th>
              </tr>
            </thead>
            <tbody></tbody>
          </table>
        </article>
      </div>
    </section>

    <!-- ④ 测试 -->
    <section id="quiz" class="panel" role="tabpanel" aria-labelledby="t-quiz">
      <article class="card">
        <h3>🧠 快速小测</h3>
        <div class="quiz" id="qbox"></div>
        <button class="btn" id="nextQ" style="display:none">下一题</button>
        <p class="legend" id="qhint"></p>
        <div class="stats">
          <div class="stat-card">
            <div class="stat-number" id="quizCorrect">0</div>
            <div class="stat-label">正确</div>
          </div>
          <div class="stat-card">
            <div class="stat-number" id="quizTotal">0</div>
            <div class="stat-label">已答</div>
          </div>
          <div class="stat-card">
            <div class="stat-number" id="quizAcc">0%</div>
            <div class="stat-label">正确率</div>
          </div>
        </div>
      </article>
    </section>
  </main>

  <script>
    /******************* Tab 切换 *******************/
    const panels = [...document.querySelectorAll('section.panel')];
    document.querySelectorAll('.tabs [role="tab"]').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.tabs [role="tab"]').forEach(b => b.setAttribute('aria-selected', 'false'));
        btn.setAttribute('aria-selected', 'true');
        panels.forEach(p => p.classList.remove('active'));
        document.getElementById(btn.id.replace('t-', '')).classList.add('active');
      })
    });

    /******************* 数据生成与线性代数 *******************/
    const rng = {
      seed: 2025, rand() { // xorshift32
        let x = this.seed |= 0; x ^= x << 13; x ^= x >>> 17; x ^= x << 5; this.seed = x; return ((x >>> 0) / 4294967296);
      }, randn() { // Box-Muller
        let u = 0, v = 0; while (!u) u = this.rand(); while (!v) v = this.rand(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
      }
    };

    function fTrue(x) { return Math.sin(2 * Math.PI * x) + 0.5 * x }

    function makeData(N, sigma) {
      const xs = [], ys = []; for (let i = 0; i < N; i++) { const x = rng.rand(); xs.push(x); ys.push(fTrue(x) + sigma * rng.randn()); }
      return { xs, ys };
    }

    function splitData(xs, ys, ratio) {
      const idx = xs.map((_, i) => i).sort(() => Math.random() - .5);
      const nTr = Math.floor(xs.length * ratio);
      const Xtr = [], ytr = [], Xva = [], yva = [];
      for (let i = 0; i < idx.length; i++) {
        const k = idx[i];
        (i < nTr ? (Xtr.push(xs[k]), ytr.push(ys[k])) : (Xva.push(xs[k]), yva.push(ys[k])));
      }
      return { Xtr, ytr, Xva, yva };
    }

    function vander(x, d) { const phi = new Array(d + 1); phi[0] = 1; for (let i = 1; i <= d; i++) phi[i] = phi[i - 1] * x; return phi; }

    function buildX(xs, d) { return xs.map(x => vander(x, d)); }

    function matT(A) { const m = A.length, n = A[0].length, B = Array.from({ length: n }, () => Array(m).fill(0)); for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) B[j][i] = A[i][j]; return B; }
    function matMul(A, B) { const m = A.length, n = A[0].length, p = B[0].length; const C = Array.from({ length: m }, () => Array(p).fill(0)); for (let i = 0; i < m; i++) for (let k = 0; k < n; k++) { const aik = A[i][k]; for (let j = 0; j < p; j++) C[i][j] += aik * B[k][j]; } return C; }

    function ridgeSolve(X, y, lam) {
      const XT = matT(X); // (d+1) x N
      const A = matMul(XT, X); // (d+1)x(d+1)
      const m = A.length; for (let i = 0; i < m; i++) { A[i][i] += (i === 0 ? 0 : lam); } // 不惩罚偏置项
      const b = matMul(XT, y.map(v => [v])); // (d+1)x1
      // 高斯消元（带部分主元）
      // 增广矩阵 [A | b]
      const aug = A.map((row, i) => row.concat(b[i]));
      for (let col = 0; col < m; col++) {
        // pivot
        let piv = col; for (let i = col + 1; i < m; i++) { if (Math.abs(aug[i][col]) > Math.abs(aug[piv][col])) piv = i; }
        if (Math.abs(aug[piv][col]) < 1e-12) continue; // 退化
        if (piv !== col) { const tmp = aug[col]; aug[col] = aug[piv]; aug[piv] = tmp; }
        // 归一
        const div = aug[col][col]; for (let j = col; j <= m; j++) aug[col][j] /= div;
        // 消元
        for (let i = 0; i < m; i++) if (i !== col) { const fac = aug[i][col]; for (let j = col; j <= m; j++) aug[i][j] -= fac * aug[col][j]; }
      }
      const w = aug.map(row => row[m]); // 解
      return w;
    }

    function predictOne(w, x) { const d = w.length - 1; let s = 0, xp = 1; for (let i = 0; i <= d; i++) { s += w[i] * xp; xp *= x; } return s; }
    function mse(X, y, w) { let e = 0; for (let i = 0; i < y.length; i++) { const phi = X[i]; let pred = 0; for (let j = 0; j < w.length; j++) pred += w[j] * phi[j]; const r = pred - y[i]; e += r * r; } return e / y.length; }

    /******************* 画布与坐标 *******************/
    const plot = document.getElementById('plot');
    const pctx = plot.getContext('2d');
    const lc = document.getElementById('lc');
    const lctx = lc.getContext('2d');

    function clearCanvas(ctx, w, h) { ctx.clearRect(0, 0, w, h); ctx.strokeStyle = '#152550'; ctx.lineWidth = 1; for (let x = 40; x < w; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke() } for (let y = 20; y < h; y += 20) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke() } }

    function xyToCanvas(x, y) { // x∈[0,1], y大致∈[-2,2]
      const W = plot.width, H = plot.height; const px = 40 + x * (W - 60); const ny = (y + 2) / 4; const py = (1 - ny) * (H - 50) + 10; return [px, py];
    }

    function drawScatter(xs, ys, color) { pctx.fillStyle = color; for (let i = 0; i < xs.length; i++) { const [px, py] = xyToCanvas(xs[i], ys[i]); pctx.beginPath(); pctx.arc(px, py, 3.5, 0, Math.PI * 2); pctx.fill(); } }

    function drawCurveW(w) { pctx.strokeStyle = '#ffffff'; pctx.lineWidth = 2; pctx.beginPath(); for (let i = 0; i <= 400; i++) { const x = i / 400; const y = predictOne(w, x); const [px, py] = xyToCanvas(x, y); i === 0 ? pctx.moveTo(px, py) : pctx.lineTo(px, py); } pctx.stroke(); }

    function drawTrue() { pctx.strokeStyle = '#5ad6ff55'; pctx.lineWidth = 1.5; pctx.beginPath(); for (let i = 0; i <= 400; i++) { const x = i / 400; const y = fTrue(x); const [px, py] = xyToCanvas(x, y); i === 0 ? pctx.moveTo(px, py) : pctx.lineTo(px, py); } pctx.stroke(); }

    /******************* 控件与状态 *******************/
    const $ = id => document.getElementById(id);
    const n = $('n'), sigma = $('sigma'), deg = $('deg'), lam = $('lam'), split = $('split');
    const nVal = $('nVal'), sVal = $('sVal'), dVal = $('dVal'), lVal = $('lVal'), spVal = $('spVal');

    [n, sigma, deg, lam, split].forEach(inp => inp.addEventListener('input', () => {
      nVal.textContent = n.value; sVal.textContent = +sigma.value; dVal.textContent = deg.value; lVal.textContent = (+lam.value).toFixed(2); spVal.textContent = Math.round(split.value * 100) + '%';
      update();
    }));

    $('regen').addEventListener('click', () => { rng.seed = (Math.random() * 1e9) | 0; makeAndSplit(); update(); });
    $('sweep').addEventListener('click', () => { sweepComplexity(); });

    // 一键演示功能
    $('demo-underfit').addEventListener('click', () => {
      // 设置为欠拟合：低复杂度，高噪声
      deg.value = 1;
      sigma.value = 0.5;
      n.value = 50;
      lam.value = 0.1;
      split.value = 0.7;
      updateDisplay();
      makeAndSplit();
      update();
    });

    $('demo-overfit').addEventListener('click', () => {
      // 设置为过拟合：高复杂度，低正则
      deg.value = 12;
      sigma.value = 0.1;
      n.value = 50;
      lam.value = 0.01;
      split.value = 0.7;
      updateDisplay();
      makeAndSplit();
      update();
    });

    $('demo-goodfit').addEventListener('click', () => {
      // 设置为良好拟合：适中复杂度，适中正则
      deg.value = 4;
      sigma.value = 0.15;
      n.value = 120;
      lam.value = 0;
      split.value = 0.7;
      updateDisplay();
      makeAndSplit();
      update();
    });

    function updateDisplay() {
      nVal.textContent = n.value;
      sVal.textContent = +sigma.value;
      dVal.textContent = deg.value;
      lVal.textContent = (+lam.value).toFixed(2);
      spVal.textContent = Math.round(split.value * 100) + '%';
    }

    let data, splitd, Xtr, ytr, Xva, yva, yAll, varY;

    function makeAndSplit() {
      data = makeData(+n.value, +sigma.value);
      const sp = +split.value;
      splitd = splitData(data.xs, data.ys, sp);
      const d = +deg.value;
      Xtr = buildX(splitd.Xtr, d); ytr = splitd.ytr;
      Xva = buildX(splitd.Xva, d); yva = splitd.yva;
      yAll = data.ys;
      // 估计方差(总体近似)
      const mu = yAll.reduce((a, b) => a + b, 0) / yAll.length;
      varY = yAll.reduce((a, b) => a + (b - mu) * (b - mu), 0) / yAll.length;
    }

    // 改进的判定逻辑
    function verdict(train, val) {
      const gap = val - train;
      const gapRatio = train > 1e-10 ? gap / train : 0;

      // 欠拟合：训练误差高（>0.3）
      if (train > 0.3) {
        return { txt: '欠拟合', cls: 'under', reason: '模型太简单，无法学习数据规律' };
      }

      // 过拟合：验证误差明显高于训练误差（间隙比>0.5）
      if (gapRatio > 0.5) {
        return { txt: '过拟合', cls: 'over', reason: '模型记住了训练数据噪声' };
      }

      // 拟合良好：训练误差低且验证误差不高
      if (train <= 0.3 && val <= 0.4) {
        return { txt: '拟合良好', cls: 'good', reason: '模型具有良好的泛化能力' };
      }

      // 兜底：根据间隙大小判断
      return gapRatio > 0.3 ?
        { txt: '过拟合', cls: 'over', reason: '验证误差明显高于训练误差' } :
        { txt: '欠拟合', cls: 'under', reason: '模型学习能力不足' };
    }

    function update() {
      // 训练当前模型
      const d = +deg.value, lambda = +lam.value;
      Xtr = buildX(splitd.Xtr, d);
      Xva = buildX(splitd.Xva, d);
      const w = ridgeSolve(Xtr, ytr, lambda);
      const mtr = mse(Xtr, ytr, w), mva = mse(Xva, yva, w);
      const gap = mva - mtr;

      // 画图
      clearCanvas(pctx, plot.width, plot.height);
      drawTrue();
      drawScatter(splitd.Xtr, ytr, '#5ad6ff');
      drawScatter(splitd.Xva, yva, '#ffb84d');
      drawCurveW(w);

      // 指标
      $('mTrain').textContent = mtr.toFixed(4);
      $('mVal').textContent = mva.toFixed(4);
      $('gap').textContent = gap.toFixed(4);

      const v = verdict(mtr, mva);
      const el = $('verdict');
      el.textContent = v.txt;
      el.className = 'verdict ' + v.cls;
    }

    // 改进的扫描复杂度函数
    function sweepComplexity() {
      const maxD = 15;
      const tr = [], va = [];
      const lam0 = +lam.value;

      for (let d = 1; d <= maxD; d++) {
        const XtrD = buildX(splitd.Xtr, d), XvaD = buildX(splitd.Xva, d);
        const w = ridgeSolve(XtrD, ytr, lam0);
        const trainMSE = mse(XtrD, ytr, w);
        const valMSE = mse(XvaD, yva, w);
        tr.push(trainMSE);
        va.push(valMSE);
      }

      // 画曲线
      clearLC();

      // 绘制训练误差曲线（蓝色）
      lctx.strokeStyle = '#5ad6ff';
      lctx.lineWidth = 2;
      lctx.beginPath();
      for (let i = 0; i < tr.length; i++) {
        const x = 30 + i * ((lc.width - 60) / (tr.length - 1));
        const y = lc.height - 30 - Math.min(tr[i] * 200, 200); // 缩放因子200
        if (i === 0) lctx.moveTo(x, y); else lctx.lineTo(x, y);
      }
      lctx.stroke();

      // 绘制验证误差曲线（橙色）
      lctx.strokeStyle = '#ffb84d';
      lctx.lineWidth = 2;
      lctx.beginPath();
      for (let i = 0; i < va.length; i++) {
        const x = 30 + i * ((lc.width - 60) / (va.length - 1));
        const y = lc.height - 30 - Math.min(va[i] * 200, 200); // 缩放因子200
        if (i === 0) lctx.moveTo(x, y); else lctx.lineTo(x, y);
      }
      lctx.stroke();

      // 绘制当前点标记
      const currentD = +deg.value;
      if (currentD >= 1 && currentD <= maxD) {
        const trainY = lc.height - 30 - Math.min(tr[currentD - 1] * 200, 200);
        const valY = lc.height - 30 - Math.min(va[currentD - 1] * 200, 200);
        const x = 30 + (currentD - 1) * ((lc.width - 60) / (maxD - 1));

        // 训练误差点（蓝色）
        lctx.fillStyle = '#5ad6ff';
        lctx.beginPath();
        lctx.arc(x, trainY, 5, 0, Math.PI * 2);
        lctx.fill();

        // 验证误差点（橙色）
        lctx.fillStyle = '#ffb84d';
        lctx.beginPath();
        lctx.arc(x, valY, 5, 0, Math.PI * 2);
        lctx.fill();
      }

      // 标注最佳点
      let bestD = 1, best = va[0];
      for (let i = 1; i < va.length; i++) {
        if (va[i] < best) {
          best = va[i];
          bestD = i + 1;
        }
      }

      // 绘制最佳点标记
      const bestX = 30 + (bestD - 1) * ((lc.width - 60) / (maxD - 1));
      const bestY = lc.height - 30 - Math.min(best * 200, 200);
      lctx.fillStyle = '#ffffff';
      lctx.beginPath();
      lctx.arc(bestX, bestY, 6, 0, Math.PI * 2);
      lctx.fill();

      // 添加文字标注
      lctx.fillStyle = '#cfe0ff';
      lctx.font = '12px sans-serif';
      lctx.fillText('最佳 d=' + bestD, bestX + 10, bestY - 10);

      // 添加图例
      lctx.fillStyle = '#5ad6ff';
      lctx.fillRect(10, 10, 15, 15);
      lctx.fillStyle = '#cfe0ff';
      lctx.font = '12px sans-serif';
      lctx.fillText('训练误差', 30, 22);

      lctx.fillStyle = '#ffb84d';
      lctx.fillRect(100, 10, 15, 15);
      lctx.fillStyle = '#cfe0ff';
      lctx.fillText('验证误差', 120, 22);
    }

    function clearLC() {
      lctx.clearRect(0, 0, lc.width, lc.height);
      lctx.strokeStyle = '#152550';
      lctx.lineWidth = 1;
      for (let x = 40; x < lc.width; x += 40) {
        lctx.beginPath();
        lctx.moveTo(x, 0);
        lctx.lineTo(x, lc.height);
        lctx.stroke();
      }
      for (let y = 20; y < lc.height; y += 20) {
        lctx.beginPath();
        lctx.moveTo(0, y);
        lctx.lineTo(lc.width, y);
        lctx.stroke();
      }
    }

    // 初始化
    makeAndSplit(); update(); clearLC();

    /******************* 处方速配 *******************/
    const rxMap = {
      bothHigh: [
        ['高', '提升模型容量/特征（更高阶或更深）', '缓解高偏差'],
        ['高', '降低正则（λ）/放宽约束', '允许模型更贴合数据'],
        ['中', '更长训练/更好优化器', '降低训练误差'],
        ['中', '改进特征工程/非线性映射', '提升表达力'],
        ['中', '清洗数据/修正标签', '降低可达误差下限']
      ],
      gap: [
        ['高', '增加正则（L2/L1/权重衰减）', '抑制过大权重降低方差'],
        ['高', '早停（以验证集为准）', '停在验证误差最低点'],
        ['高', '增加数据量/数据增强', '平均噪声、降低方差'],
        ['中', '简化模型/减少特征', '降低自由度'],
        ['中', '交叉验证与超参搜索', '更稳健的估计']
      ],
      plateau: [
        ['高', '调学习率/优化器，延长训练', '更好收敛，降低训练误差'],
        ['中', '归一化/标准化特征', '加速收敛、稳定优化'],
        ['中', '检查梯度消失/爆炸', '必要时加残差/正则']
      ],
      noisy: [
        ['高', '数据清洗：去异常/修正标签', '减少不可约噪声'],
        ['高', '鲁棒训练：标签平滑/噪声正则', '减弱噪声影响'],
        ['中', '更多数据/增强', '以量取胜，降低方差']
      ]
    };

    $('suggest').addEventListener('click', () => {
      const key = $('sym').value; const rows = rxMap[key] || [];
      const tbody = document.querySelector('#rxTable tbody'); tbody.innerHTML = '';
      rows.forEach((r, i) => {
        const tr = document.createElement('tr'); tr.innerHTML = `<td>${i + 1}（${r[0]}）</td><td>${r[1]}</td><td>${r[2]}</td>`; tbody.appendChild(tr);
      })
    });

    /******************* 小测试 *******************/
    const Q = [
      {
        q: '训练误差高、验证误差也高，优先采取：',
        opts: ['增强正则', '增加模型容量/特征', '早停', '更强数据增强'],
        a: 1,
        hint: '高偏差 ⇒ 先提表达力，必要时再降正则/延长训练'
      },
      {
        q: '训练误差很低但验证误差明显更高，首先应：',
        opts: ['增大学习率', '加入或增大正则/早停', '增加模型深度', '去掉交叉验证'],
        a: 1,
        hint: '高方差 ⇒ 先控方差（正则/早停）'
      },
      {
        q: '缓解过拟合，下列哪项不是优先策略？',
        opts: ['收集更多数据/数据增强', '增大 L2（λ）', '降低模型复杂度', '将训练轮数翻倍且不早停'],
        a: 3,
        hint: '无早停久训通常加剧过拟合'
      },
      {
        q: '标签噪声与异常点较多时，首选：',
        opts: ['提升多项式阶 d', '数据清洗与鲁棒训练（如标签平滑）', '降低学习率', '去掉验证集'],
        a: 1,
        hint: '先降低不可约噪声/减弱噪声影响'
      },
      {
        q: '复杂度扫描中验证误差的最低点意味着应：',
        opts: ['把 d 设在最低点或启用早停', '继续增大 d', '关闭正则', '只看训练误差'],
        a: 0,
        hint: '验证曲线最低点 ≈ 最佳容量/早停点'
      },
      {
        q: '欠拟合时通常应降低正则或提升模型容量（对/错）',
        opts: ['√', '×'],
        a: 0,
        hint: '降偏差两路：放松约束 + 增强表达力'
      },
      {
        q: '过拟合时，减少特征或降低多项式阶有助于提升泛化（对/错）',
        opts: ['√', '×'],
        a: 0,
        hint: '降模型自由度 ⇒ 降方差'
      },
      {
        q: '早停主要用于欠拟合场景，防止模型“学不够”（对/错）',
        opts: ['√', '×'],
        a: 1,
        hint: '早停针对过拟合：在验证误差拐点停止'
      },
      {
        q: '增大 L2 正则通常降低方差、提高偏差，可能缓解过拟合但带来欠拟合风险（对/错）',
        opts: ['√', '×'],
        a: 0,
        hint: '典型偏差-方差权衡'
      },
      {
        q: '出现“训练低、验证高”的大间隙时，首选继续久训以压低验证误差（对/错）',
        opts: ['√', '×'],
        a: 1,
        hint: '应先控方差：正则/早停/更多数据/简化模型'
      }
    ];

    // 小测试相关变量
    let qi = 0;
    let quizCorrect = 0;
    let quizTotal = 0;

    // 更新统计显示（美观卡片式）
    function updateQuizStats() {
      document.getElementById('quizCorrect').textContent = quizCorrect;
      document.getElementById('quizTotal').textContent = quizTotal;
      document.getElementById('quizAcc').textContent = quizTotal > 0 ? Math.round(quizCorrect / quizTotal * 100) + '%' : '0%';
    }

    // 渲染题目（美观优化）
    function loadQ() {
      const qbox = document.getElementById('qbox');
      const qhint = document.getElementById('qhint');
      const nextQ = document.getElementById('nextQ');
      // 完成提示
      if (qi >= Q.length) {
        qbox.innerHTML = '<div class="legend" style="font-size:18px;line-height:2">🎉 本轮测试已完成</div>';
        qhint.textContent = '做得好！你可以切换到其他选项卡继续学习。';
        nextQ.style.display = 'none';
        updateQuizStats();
        return;
      }
      const it = Q[qi];
      qbox.innerHTML = '';
      qhint.textContent = '';
      nextQ.style.display = 'none';
      const h = document.createElement('div');
      h.className = 'legend';
      h.textContent = 'Q' + (qi + 1) + '：' + it.q;
      qbox.appendChild(h);
      it.opts.forEach((t, i) => {
        const el = document.createElement('div');
        el.className = 'opt';
        el.textContent = t;
        el.onclick = () => {
          quizTotal++;
          if (i === it.a) {
            el.classList.add('correct');
            qhint.textContent = '✅ 正确！' + it.hint;
            quizCorrect++;
          } else {
            el.classList.add('wrong');
            qhint.textContent = '❌ 再想想：' + it.hint;
          }
          [...qbox.querySelectorAll('.opt')].forEach(e => e.onclick = null);
          nextQ.style.display = 'inline-block';
          updateQuizStats();
        };
        qbox.appendChild(el);
      });
      updateQuizStats();
    }

    // 下一题按钮逻辑
    document.getElementById('nextQ').onclick = () => {
      qi++;
      loadQ();
    };

    // 初始化
    loadQ();
    updateQuizStats();
  </script>
</body>

</html>